Skip to content

feat(stats): give the collector an outcome ledger - #68

Open
bdchatham wants to merge 3 commits into
mainfrom
brandon2/plt-1075-outcome-ledger
Open

feat(stats): give the collector an outcome ledger#68
bdchatham wants to merge 3 commits into
mainfrom
brandon2/plt-1075-outcome-ledger

Conversation

@bdchatham

@bdchatham bdchatham commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes PLT-1075. First code of PLT-466; PLT-1074 settled the one assumption that gated it.

Why

stats.Collector counts what the sender submitted. stats.InclusionTracker counts what became of those submissions. grep -c Collector stats/inclusion_tracker.go returns 0 — the tracker holds no reference to the collector, and the collector never learns an outcome. Neither holds both halves, so neither can say what fraction of offered work took effect.

A run can report a million accepted, near-perfect inclusion and a healthy p99 while every transaction failed. Nothing in the output says so.

This adds the vocabulary and the ledger. It changes no behaviour — the tracker does not call it yet, the counts stay zero, and a run reads exactly as it does today.

What

Result names six terminal states. Two distinctions carry the point of the type:

  • Committed and Failed separate a transaction that did what the workload asked from one that burned its gas doing nothing. An inclusion count cannot tell those apart, which is the defect this feature exists to remove.
  • Unknown is not Expired. One means the run did not see; the other means the chain did not take it. That decides whether a low goodput ratio is a finding about the chain or a finding about the run.

Failed names what a receipt reports, not a cause. A receipt carries one status bit — an explicit revert, an out-of-gas and an invalid opcode all arrive there, and separating them needs a trace call per transaction that the per-block read budget forbids. Calling it a revert would tell an operator the contract rejected the call, which the run cannot see.

RecordResult(key, result) keys on the OperationKey PLT-1025 already landed, and adds rather than overwrites. Callers reach it from more than one goroutine: a block match and a reap sweep both report outcomes.

The result strings are a one-way door. A dashboard query and a saved report both carry them, so a rename orphans every one. A test pins them.

Verification

Tests were written before the code. The first red was the compile error:

c.RecordResult undefined (type *stats.Collector has no field or method RecordResult)

Then each guard was checked by breaking what it covers, because a test nobody has watched fail has not shown it tests anything:

Defect introduced Result
results[result] = 1 instead of ++ 2 tests fail
Failed reads ResultCommitted's slot 3 tests fail, naming both states
"dropped_at_cap" renamed the string test fails
the lock removed from RecordResult WARNING: DATA RACE

The concurrency test mirrors the tracker's real shape rather than being decoration — two goroutines reporting outcomes is what the head loop and the reap loop will do.

gofmt   clean
vet     clean
lint    0 issues
tests   14 packages ok, 0 failures
race    ok  github.com/sei-protocol/sei-load/stats

make verify stops at check-bindings locally on a missing solc. This diff touches no Solidity and CI runs that step properly, but I am not claiming a pass I did not observe.

Requirements

TOT-001, TOT-004, TOT-005, TOT-006, TOT-016. Tasks T003 to T006.

Reviewing

Small on purpose. PLT-466 is split into six phases and seven PRs so no single review runs to the size of the contract registry change (+3203 / 35 files, 15 review submissions). This is the foundational one: a type, a method, six fields.

The thing worth arguing about is the state set — six is a claim that these partition every accepted transaction, and the conservation identity in a later PR has to hold over exactly them. Easier to change now than after the tracker populates them.


Renamed Outcome to Result after review feedback, and force-pushed rather than stacking a rename commit. Identifiers only: the six strings (committed, failed, expired, dropped_at_cap, dropped_at_handoff, unknown) are unchanged, and TestResultNamesAreStable proves it. stats/outcome.go is now stats/result.go.

The pre-existing InclusionTracker.recordOutcome is deliberately untouched — it is a different method emitting OTel counters, and renaming it belongs in the PR that replaces it.

@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Additive stats API and typed metric labels; no production path populates outcome counts yet, so observable run behavior is unchanged.

Overview
Introduces a typed Outcome vocabulary (committed, failed, expired, drops, status unavailable, plus an unrecorded sentinel) and Collector.RecordOutcome, which accumulates terminal counts per OperationKey and surfaces them on OperationStats via GetOperationStats. Invalid or unset outcomes fold into Unrecorded with a one-time log so classification bugs stay visible without spamming.

InclusionTracker OTel inclusion_outcome labels now use Outcome.String() instead of string literals (expired / dropped_at_cap only today); the tracker still does not call RecordOutcome, so run reports are unchanged until a follow-up wires receipts and the collector. Adds stats/doc.go and property tests (per-key accumulation, stable wire names, concurrency).

Reviewed by Cursor Bugbot for commit ec61136. Bugbot is set up for automated code reviews on this repo. Configure here.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A well-tested, behaviour-neutral addition of an Outcome vocabulary and a per-operation outcome ledger to stats.Collector. No blockers; a misplaced doc comment and two invalid-value handling choices are worth addressing before the tracker starts populating these counts.

Findings: 0 blocking | 4 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] InclusionTracker.recordOutcome still passes bare string literals ("dropped_at_cap", "expired") as the metric label, while this PR introduces Outcome.String() producing those same strings. Two parallel vocabularies for one concept will drift; when the tracker is wired up in a follow-up, switch those call sites to the Outcome constants so the "one-way door" strings have a single source.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread stats/collector.go
// recordOperation counts one attempt for key and, on success, adds its latency
// to that operation's samples. The bound is the same one recordLatency applies
// to the pooled window.
// RecordOutcome counts one terminal outcome under the key the send path already

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] RecordOutcome was inserted between recordOperation's doc comment and recordOperation itself, with no blank line between the two comment blocks. Go treats the whole run of lines 91-102 as RecordOutcome's doc comment, so godoc now shows "recordOperation counts one attempt for key..." as the first sentence of RecordOutcome, and recordOperation is left undocumented. Move those three lines back down to directly above func (c *Collector) recordOperation.

Comment thread stats/outcome.go Outdated

const (
// OutcomeCommitted is a receipt reporting a successful status.
OutcomeCommitted Outcome = iota

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] OutcomeCommitted is the zero value, so any Outcome field or variable that is never explicitly set records as a successful commit. Given that Committed-vs-Failed is the distinction this type exists to make, a zero-value slip inflates goodput silently and in the safest-looking direction. Consider reserving zero for an unset/invalid state (an unexported outcomeUnset Outcome = iota before OutcomeCommitted) so a missed assignment is detectable rather than counted as success.

Comment thread stats/collector.go Outdated
// It takes the same lock as RecordTransaction, and callers reach it from more
// than one goroutine: a block match and a reap sweep both report outcomes.
func (c *Collector) RecordOutcome(key OperationKey, outcome Outcome) {
if outcome >= outcomeCount {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] An out-of-range outcome is dropped silently. The doc on Outcome states the states partition every accepted transaction and that the conservation identity holds over them at shutdown — a silent drop breaks that identity with no signal, and it is exactly the case a caller bug would produce. Folding it into OutcomeUnknown (or counting it separately) keeps the total conserved and makes the misuse visible in the report.

The collector counts what the sender submitted. The inclusion tracker counts what
became of those submissions. Neither holds both halves, so neither can say what
fraction of offered work took effect.

This adds the vocabulary and the ledger, and changes no behaviour. The tracker
does not call it yet.

Result names six terminal states. Two distinctions carry the point. Committed and
Failed separate a transaction that did what the workload asked from one that
burned its gas doing nothing, which an inclusion count cannot tell apart. Unknown
separates "the run did not see" from "the chain did not take it", which decides
whether a low goodput ratio is a finding about the chain or about the run.

Failed names what a receipt reports rather than a cause. A receipt carries one
status bit, and separating a revert from an out-of-gas needs a trace call per
transaction that the per-block read budget forbids.

RecordResult keys on the OperationKey the send path already labels its metrics
with, and adds rather than overwrites. Callers reach it from more than one
goroutine, because a block match and a reap sweep both report results.

The result strings are a one-way door: a dashboard query and a saved report both
carry them, so a test pins them. The strings are unchanged by the type's name.

Every guard was checked by breaking what it covers. Overwriting instead of adding
fails two tests. Folding Failed into Committed fails three. A drifted name fails
the string test. Dropping the lock reports a data race.

Requirements: TOT-001, TOT-004, TOT-005, TOT-006, TOT-016. Tasks T003 to T006.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham
bdchatham force-pushed the brandon2/plt-1075-outcome-ledger branch from cb1480a to acc718d Compare August 26, 2026 21:49
@bdchatham bdchatham changed the title feat(stats): give the collector an outcome ledger feat(stats): give the collector a result ledger Aug 26, 2026
The collector counts what the sender submitted. The inclusion tracker counts what
became of those submissions. Neither holds both halves, so neither can say what
fraction of offered work took effect.

This adds the vocabulary and the ledger. Nothing reports an outcome yet.

Outcome names six terminal states. Committed and Failed separate a transaction
that did what the workload asked from one that burned its gas doing nothing.
StatusUnavailable separates "the run did not see" from "the chain did not take
it", which decides whether a low goodput ratio is a finding about the chain or
about the run.

The zero value is a sentinel, not a state. Committed at index 0 would mean an
unassigned variable, a switch matching no case, or an early return counts as a
commit, silently, which is the failure the type exists to remove. An unset or
out-of-range value counts as Unrecorded instead: it has no legitimate producer,
so a non-zero count means sei-load has a bug and nothing else explains it. The
first one logs, once per run, because a systematic bug would otherwise write a
line per transaction. A run never fails over a counting bug.

recordOutcome now takes an Outcome rather than a string. The metric label was
already fed by bare literals while Outcome.String() produced the same values, so
one wire contract had two independent sources and the test pinned the one nothing
used. A literal still compiles, so this makes re-splitting unnatural rather than
impossible.

status_unavailable rather than unknown: a reader seeing unknown beside expired
cannot tell a chain finding from a measurement finding, which is the confusion
the state exists to prevent. dropped_at_handoff stays, because both alternatives
collided with the dispatcher's own load shed, which RunSummary.Dropped already
counts and which means the transaction never reached the chain at all.

stats/doc.go carries the type map, the three sentinel rules, and the three lock
domains. Lifecycle and ownership are marked absent rather than invented, because
both describe the tracker loop this change does not add.

Guards proven by breaking what they cover: Committed back at index 0, the
out-of-range value vanishing, a drifted frozen string.

Requirements: TOT-001, TOT-004, TOT-005, TOT-006, TOT-016. Tasks T003 to T006.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bdchatham bdchatham changed the title feat(stats): give the collector a result ledger feat(stats): give the collector an outcome ledger Aug 26, 2026
@bdchatham

Copy link
Copy Markdown
Contributor Author

xreview round 2 — RESOLVED, 0 open findings

Four blinded lenses reviewed acc718d: systems (assigned dissenter), platform, idiom, prose. Round 1 returned six correctness-grade findings and every lens dissented. Round 2 at 034330a resolves all of them. Ledger: bdchatham-designs/designs/sei-load-observability/xreview/plt-1075-result-ledger.md.

The finding that mattered

The zero value of the type was Committed. Reproduced:

unset Outcome -> Committed=1 Failed=0  String()="committed"

A switch matching no case, a map lookup that misses, an early return before assignment — each silently recorded a commit. TOT-004 forbids exactly that, and the spec lists fail-closed among its own anchors.

Now outcomeUnset sits at index 0 and real states start at 1. An unset or out-of-range value counts as Unrecorded, which has no legitimate producer: a non-zero count means sei-load has a bug, with no competing explanation. The first one logs, once per run. It never panics — spec.md says nothing here fails a run, and discarding an hour of load over a counting bug is the wrong trade.

Two lenses reached this independently, and idiom established that the general Go answer runs the other way (reflect.Kind, time.Month, otel/codes.Code all refuse a real state at 0) before overriding it on this package's own documentation.

One contract had two sources, and the test pinned the wrong one

Outcome.String() returned dropped_at_cap and expired. The values actually reaching the OTel label were bare literals at inclusion_tracker.go:127 and :292. A rename of either orphaned every dashboard query while TestOutcomeNamesAreStable stayed green.

recordOutcome now takes an Outcome. Honest limit: a literal at the emit site still compiles, so this makes re-splitting unnatural rather than impossible.

Two reviewers reversed themselves

The dissenter withdrew twice. It expected RecordOutcome to reintroduce the TOT-010 stall, built the experiment, measured p99.9=92.1µs against a 94.5µs baseline, and withdrew. Asked later what stops a batched API becoming dead code: "Nothing. That is the reason not to add it."

Platform withdrew a rename it had asked for, after finding both replacements collided with RunSummary.Dropped — the dispatcher's own load shed, where the transaction never reached the chain at all.

Also fixed

ResultOutcome, because the wire label is already outcome and two of its live values are two of this type's strings. The wire is the one-way door; the identifier is the two-way door. It also makes tasks.md's ticks true rather than needing correction.

unknownstatus_unavailable. A reader seeing unknown: 4200 beside expired: 12 cannot tell a chain finding from a measurement finding, which is the confusion the state exists to prevent.

Plus: the merged doc comment (go doc opened by describing a different, unexported method), four present-tense claims about a pipeline that does not exist, the false partition claim, the Failed-means-two-things collision documented at the point of use, and a new stats/doc.go with lifecycle and ownership marked absent rather than invented.

Guards proven by breaking what they cover

Defect reintroduced Result
Committed back at index 0 TestTheZeroValueIsNotASuccess fails
out-of-range value vanishing TestAnUnknownOutcomeStaysVisible fails
a drifted frozen string TestOutcomeNamesAreStable fails
a literal at the emit site still compiles — stated, not overclaimed

gofmt clean · go vet clean · golangci-lint 0 issues · 14 packages pass · -race passes. make verify still stops at check-bindings on a missing local solc; not claimed as a pass.

Found outside this diff, needs its own change

NightlyHTTPErrorRateHigh divides by seiload_http_errors_total, which this binary has not emitted for months. The expression returns an empty vector, so the alert can never fire — indistinguishable from a healthy system. Three more orphaned panels alongside it. Different repo; needs a ticket naming the paths before this merges.

@bdchatham

Copy link
Copy Markdown
Contributor Author

The orphaned-metrics finding from the review is filed as PLT-1081 (Low). It carries the file paths and reference counts, so it survives a re-read: NightlyHTTPErrorRateHigh cannot fire, and 18 references across three dashboards name metrics this binary no longer emits.

Not a blocker here — different repo — but it is the evidence that this PR's one-way-door comment is describing something that has already happened once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant